滑動條主體很單純,使用 div 就可以完成,不過握把的部分需要使用 SVG 實現。
結構概念如下圖:
首先加入 template 部分。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div class="slider-stubborn">
<div class="track " />
<svg class="thumb">
</svg>
</div>
</template>
相當單純。(´,,•ω•,,)
不過可以預期「握把」部分有許多內部邏輯,讓我們把「握把」獨立一個元件吧。( ´ ▽ ` )ノ
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg class="thumb">
</svg>
</template>
<script setup lang="ts">
</script>
<style scoped lang="sass">
</style>
引入 thumb 元件。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div class="slider-stubborn">
<div class="track " />
<slider-thumb />
</div>
</template>
<script setup lang="ts">
import SliderThumb from './slider-stubborn-thumb.vue'
...
</script>
...
接下來讓我們加入程式邏輯吧。
既然名字裡有「滑動條」,還是該優先完成滑動條的基本功能。
首先來定義一下元件參數與事件。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div class="slider-stubborn">
<div class="track " />
<slider-thumb />
</div>
</template>
<script setup lang="ts">
...
// #region Props
interface Props {
modelValue: number;
disabled?: boolean;
min?: number;
max?: number;
/** 握把被拉長的最大長度 */
maxThumbLength?: number;
thumbSize?: number;
thumbColor?: string;
trackClass?: string;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
disabled: false,
min: 0,
max: 100,
maxThumbLength: 200,
thumbSize: 20,
thumbColor: '#34c6eb',
trackClass: ' bg-[#EEE]',
});
// #region Emits
const emit = defineEmits<{
'update:modelValue': [value: Props['modelValue']];
}>();
// #endregion Emits
</script>
...
參數基本上和 input slider 的內容相同,emit 中的 update:modelValue
則用於實現 v-model 邏輯。
現在來實作元件 v-model 功能吧。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div class="slider-stubborn">
<div class="track " />
<slider-thumb />
</div>
</template>
<script setup lang="ts">
...
const modelValue = useVModel(props, 'modelValue', emit);
</script>
...
這裡使用 VueUse 的 useVModel
實現,你沒看錯,一行就完成惹。(≖‿ゝ≖)✧
現在只要 modelValue 數值發生變化,就會自動 emit 出去。
Vue 3.4 以上可以使用 defineModel,更簡潔方便。( ´ ▽ ` )ノ
接著讓我們加入容器相關邏輯與軌道樣式。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div
ref="sliderRef"
class="slider-stubborn relative py-3"
@mousedown="(e) => e.preventDefault()"
@mousemove="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
@touchmove="(e) => e.preventDefault()"
>
<div
class="rounded-full h-[8px]"
:class="props.trackClass"
/>
<slider-thumb />
</div>
</template>
<script setup lang="ts">
...
const sliderRef = ref<HTMLDivElement>();
const mouseInSlider = reactive(useMouseInElement(
sliderRef, { eventFilter: throttleFilter(15) }
));
const sliderSize = reactive(useElementSize(sliderRef));
</script>
...
e.preventDefault()
用來阻止原本的相關事件,接著建立兩個變數:
讓我們判斷使用者是否「按住」滑動條。
src\components\slider-stubborn\slider-stubborn.vue
...
<script setup lang="ts">
...
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
</script>
...
沒錯,一樣一行解決。♪( ◜ω◝و(و
這就是 Vue 3 Composition API 的精隨,我們可以封裝各種功能,並在元件中簡單複用。
接下來計算滑鼠拉動位置與滑動條相關的計算邏輯。
src\components\slider-stubborn\slider-stubborn.vue
...
<script setup lang="ts">
...
/** 將目前數值根據比例轉換成 0 至 100 */
const ratio = computed(() => {
const value = modelValue.value / (props.max - props.min) * 100;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
});
/** 滑鼠目前位置在滑動條中的比率(0~100) */
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
});
/** 計算並持續同步 modelValue 數值 */
watch(() => [mouseRatio, isHeld], () => {
if (props.disabled || !isHeld.value) return;
modelValue.value = (props.max - props.min) * mouseRatio.value / 100;
}, { deep: true })
</script>
...
這樣我們就完成一個稱職的滑動條該有的基本邏輯了。◝( •ω• )◟
接著加上握把的部分,將參數傳入並加上外觀,看看效果如何。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
width="50"
height="50"
viewBox="-50 -50 100 100"
class="thumb absolute pointer-events-none"
>
<path
d="M0 0 Z"
:stroke="props.thumbColor"
stroke-width="30"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</template>
<script setup lang="ts">
interface Props {
min: number;
max: number;
maxThumbLength: number;
thumbSize: number;
thumbColor: string;
disabled: boolean;
isHeld: boolean;
ratio: number;
mouseRatio: number;
sliderSize: {
width: number,
height: number,
};
}
const props = withDefaults(defineProps<Props>(), {});
</script>
<style scoped lang="sass">
.thumb
top: 50%
transform: translate(-50%, -50%)
</style>
說明如下:
M0 0 Z
的意思表示「在座標 0, 0 的位置畫一個封閉的線」,所以看起來會是一個點現在把相關參數傳入 thumb 元件。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div
ref="sliderRef"
...
>
...
<slider-thumb
v-bind="props"
:is-held="isHeld"
:ratio="ratio"
:mouse-ratio="mouseRatio"
:slider-size="sliderSize"
/>
</div>
</template>
...
看起來像個滑動條了。( ´ ▽ ` )ノ
現在把滑動條數值綁定樣式,讓握把可以被滑鼠拉動吧。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
...
:style="svgStyle"
>
...
</svg>
</template>
<script setup lang="ts">
...
const svgStyle = computed<CSSProperties>(() => {
let leftValue = props.isHeld ? props.mouseRatio : props.ratio;
if (props.disabled) {
leftValue = props.ratio
}
return {
left: `${leftValue}%`,
}
});
</script>
...
同時調整一下 basic-usage 內容。
src\components\slider-stubborn\examples\basic-usage.vue
<template>
<div class="flex flex-col gap-4 w-full border border-gray-300 p-6">
目前數值:{{ Math.floor(value) }}
<slider-stubborn v-model="value" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import SliderStubborn from '../slider-stubborn.vue';
const value = ref(50);
</script>
終於是個稱職的滑動條了。
現在讓我們進入重頭戲部分,來設計會伸長的握把。( ゚∀。)
首先取得畫面、SVG 相關的資訊,用於後續的計算邏輯。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
ref="svgRef"
...
>
...
</svg>
</template>
<script setup lang="ts">
...
/** 視窗尺寸,用來防止物體意外超出畫面範圍 */
const windowSize = reactive(useWindowSize());
const svgRef = ref<SVGElement>();
const mouseInSvg = reactive(useMouseInElement(
svgRef, { eventFilter: throttleFilter(15) }
));
/** 以 svg 中心為 0 點 */
const mousePosition = computed(() => ({
x: mouseInSvg.elementX - mouseInSvg.elementWidth / 2,
y: mouseInSvg.elementY - mouseInSvg.elementHeight / 2,
}));
const svgStyle = computed<CSSProperties>(...);
</script>
...
接著來新增握把相關參數。(◜௰◝)
這裡我們使用 SVG 的 path 元素繪製線條,使用 Q 指令(quadratic Bézier curve)繪製二次貝茲曲線。
Q 指令很單純,起點與終點共用同一個控制點,所以只要定義三個點即可,如下圖。
path 的指令相當多樣,有興趣的讀者可見此連結:MDN:SVG Paths
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
...
>
<path
:d="pathD"
...
/>
</svg>
</template>
<script setup lang="ts">
...
/** svg path 的 Q 指令需要控制點與終點,表達二次貝茲曲線
*
* [文件](https://www.oxxostudio.tw/articles/201406/svg-04-path-1.html)
*/
const ctrlPoint = ref({ x: 0, y: 0 });
const endPoint = ref({ x: 0, y: 0 });
const pathD = computed(() => {
const { x: ctrlX, y: ctrlY } = ctrlPoint.value;
const { x: endX, y: endY } = endPoint.value;
return [
`M0 0`,
`Q${ctrlX} ${ctrlY}, ${endX} ${endY}`,
].join(' ')
});
</script>
...
接著處理終點動畫。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
...
>
<path
:d="pathD"
...
/>
</svg>
</template>
<script setup lang="ts">
...
/** 處理終點動畫 */
useIntervalFn(() => {
if (!props.isHeld || !props.disabled) return;
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
const length = getVectorLength(newPoint);
// 如果超過 maxThumbLength,則將 newPoint 限制在 maxThumbLength 範圍內
if (length > props.maxThumbLength) {
newPoint.x = newPoint.x * scaleFactor + noise;
newPoint.y = newPoint.y * scaleFactor + noise;
}
endPoint.value = newPoint;
}, 15)
</script>
...
這裡使用 useIntervalFn
實現,可能會有讀者好奇為甚麼不使用 useRafFn
?useRafFn
基於 requestAnimationFrame
,理論上效果不是應該更好嗎?
理論上是這樣沒錯,但實際上比較 useIntervalFn
、useRafFn
兩者後,useIntervalFn
的動畫效果比較流暢,雖然不知道為甚麼就是了。ヾ(◍'౪`◍)ノ゙
如果有讀者知道為甚麼歡迎留言補充。(´▽`ʃ♡ƪ)
現在讓我們在 basic-usage 新增停用 checkbox。
src\components\slider-stubborn\examples\basic-usage.vue
<template>
<div class="flex flex-col gap-4 w-full border border-gray-300 p-6">
<label class=" flex items-center border p-4 rounded">
<input
v-model="disabled"
type="checkbox"
>
<span class="ml-2">
停用按鈕
</span>
</label>
目前數值:{{ Math.floor(value) }}
<slider-stubborn
v-model="value"
:disabled="disabled"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import SliderStubborn from '../slider-stubborn.vue';
const disabled = ref(false);
const value = ref(50);
</script>
可以注意到現在停用後可以拉長拉把了。( ´ ▽ ` )ノ
路人:「握把被切掉啦!Σ(ˊДˋ;)」
鱈魚:「那就讓 SVG 大一點吧!( ゚∀。)」
路人:「這樣會不會導致 SVG 超出畫面時,產生多餘的滾動條嗎?(*´・д・)」
鱈魚:「也是啦,那就讓我們動態計算 SVG 尺寸吧。◝( •ω• )◟」
讓我們根據滑鼠拉動的位置,動態計算 SVG 尺寸。
新增 SVG 尺寸計算邏輯並加上 border 觀察一下。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
ref="svgRef"
v-bind="svgAttrData"
:style="svgStyle"
class="thumb absolute pointer-events-none"
>
...
</svg>
</template>
<script setup lang="ts">
...
/** 以 svg 中心為 0 點 */
const mousePosition = computed(...);
/** 動態調整 svg 尺寸,避免拉動 slider 時頁面產生多餘滾動條 */
const svgSize = ref(props.maxThumbLength + props.thumbSize * 1.5);
const svgAttrData = computed(() => ({
width: svgSize.value,
height: svgSize.value,
viewBox: [
svgSize.value / -2,
svgSize.value / -2,
svgSize.value,
svgSize.value,
].join(' ')
}));
/** svgSize 動畫效果 */
useIntervalFn(() => {
let newSize = Math.max(
Math.abs(mousePosition.value.x),
Math.abs(mousePosition.value.y),
props.thumbSize,
) * 2;
// 限制最大尺寸
if (newSize > props.maxThumbLength * 2) {
newSize = props.maxThumbLength * 2;
}
// 如果按鈕被按住或是停用,SVG 尺寸與握把相同即可
if (!props.isHeld || !props.disabled) {
newSize = props.thumbSize;
}
/** 1.5 是安全係數 */
newSize += props.thumbSize * 1.5;
const delta = newSize - svgSize.value;
// 限制精度,減少不必要的運算
if (Math.abs(delta) < 0.01) {
svgSize.value = newSize;
return;
}
// 長大要快,縮小要慢,避免切到正在播放動畫的握把
if (delta > 0) {
svgSize.value += delta;
} else {
svgSize.value += delta / 10;
}
}, 15)
const svgStyle = computed<CSSProperties>(...);
...
</script>
...
可以看到 SVG 尺寸會隨著拉動的範圍變化了!◝( •ω• )◟
不過握把沒有在放開的時候回歸,讓我們補充一下回歸邏輯。
src\components\slider-stubborn\slider-stubborn-thumb.vue
...
<script setup lang="ts">
import anime from 'animejs';
...
/** 放開時,播放回彈動畫 */
watch(() => props.isHeld, (value) => {
if (value) return;
anime({
targets: endPoint.value,
x: 0,
y: 0,
easing: 'easeOutElastic',
duration: 300,
});
})
/** 處理終點動畫 */
useIntervalFn(...)
</script>
...
使用 anime.js 輕鬆實現。
現在放開後,握把會彈回原點了!ᕕ( ゚ ∀。)ᕗ
接著來實作「被拉長的握把會隨著長度變細」規格吧。
這個很簡單,只要將長度映射寬度即可。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg ... >
<path
...
:stroke-width="strokeWidth"
...
/>
</svg>
</template>
<script setup lang="ts">
import anime from 'animejs';
...
const pathD = computed(...);
/** 目前長度 */
const length = computed(() => {
/** 因為起點是 0, 0,所以變化量直接等於終點座標 */
const delta = {
x: endPoint.value.x,
y: endPoint.value.y,
}
return getVectorLength(delta);
});
const strokeMinWidth = computed(() => Math.max(props.thumbSize * 0.1, 5));
const strokeWidth = computed(() => mapNumber(
length.value,
0,
props.maxThumbLength,
props.thumbSize,
strokeMinWidth.value,
));
...
</script>
...
現在握把會越拉越細了。ԅ(´∀` ԅ)
最後讓我們實現最關鍵的「被拉長後會有 Q 彈的慣性效果」規格吧。
這裡會稍為複雜一點,需要用上一點物理概念,首先定義相關係數。
src\components\slider-stubborn\slider-stubborn-thumb.vue
...
<script setup lang="ts">
...
const ctrlPoint = ref({ x: 0, y: 0 });
/** 控制點速度,用來模擬震盪效果 */
let ctrlPointVelocity = { x: 0, y: 0 };
/** 彈性係數,根據目前長度映射,模擬拉越緊震動越快 */
const ctrlPointStiffness = computed(() => mapNumber(
length.value,
0, props.maxThumbLength,
3.5, 4.5,
));
/** 速度衰減率,根據目前長度映射,模擬越短震動越快停止
*
* 範圍 0 ~ 1。越小衰減越快
*/
const ctrlPointDamping = computed(() => mapNumber(
length.value,
0, props.maxThumbLength,
0.85, 0.75,
));
...
</script>
...
大家可以微調係數,看看會有甚麼效果喔。ԅ(´∀` ԅ)
接著處理控制點動畫並刪除觀察用的 border。
src\components\slider-stubborn\slider-stubborn-thumb.vue
<template>
<svg
...
class="thumb absolute pointer-events-none"
>
...
</svg>
</template>
<script setup lang="ts">
...
/** 處理控制點 */
useIntervalFn(() => {
const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}
const dx = targetPoint.x - ctrlPoint.value.x
const dy = targetPoint.y - ctrlPoint.value.y
// 彈力公式:F = -k * x (k 是彈性係數,x 是位移)
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
// 阻尼,減少速度
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
if (Math.abs(ctrlPointVelocity.x) < 0.001 && Math.abs(ctrlPointVelocity.y) < 0.001) {
ctrlPointVelocity = { x: 0, y: 0 }
return;
}
// 更新座標
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y
// 不可以超出畫面
if (Math.abs(ctrlPoint.value.x) > windowSize.width / 2) {
ctrlPoint.value.x = ctrlPoint.value.x > 0 ? windowSize.width / 2 : -windowSize.width / 2;
}
if (Math.abs(ctrlPoint.value.y) > windowSize.height / 2) {
ctrlPoint.value.y = ctrlPoint.value.y > 0 ? windowSize.height / 2 : -windowSize.height / 2;
}
}, 15)
</script>
...
恭喜!我們完成一個 Q 彈又固執的滑動條了!✧⁑。٩(ˊᗜˋ*)و✧⁕。
最後讓我們追加點 a11y 標記吧。
src\components\slider-stubborn\slider-stubborn.vue
<template>
<div
ref="sliderRef"
role="slider"
v-bind="ariaAttr"
...
>
...
</div>
</template>
<script setup lang="ts">
...
const ariaAttr = computed(() => ({
'aria-valuemin': props.min,
'aria-valuemax': props.max,
'aria-valuenow': modelValue.value,
'aria-orientation': 'horizontal',
'aria-disabled': props.disabled,
} as const));
</script>
...
完美,收工。(´,,•ω•,,)
有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭
以上程式碼已同步至 GitLab,大家可以前往下載: